作者:陈广
日期:2020-7-10
单片机课程到了实训周,实训的题目定为 51 单片机串口编程。为方便学习,专门写一篇文章介绍 51 单片机的串口编程。虽然可以使用 Proteus 来模拟串口,但相当麻烦,用起来也很不顺手,最好是有一块开发板。经过 N 次改版,我设计的 51 开发板总算是成型了。下图是打印的第五块板,第一块板画了 3 次才定板。其中的心酸和曲折真是一言难尽,即使是这第五版也还是有问题,后面会讲。
STC89C52 单片机内置了对串口的支持,我们在学习单片机中断时,已经了解了串口是有一个串断号的。要进行串口编程,实际上也就是针对寄存器进行配置即可。我这里只针对我需要使用的地方进行讲解,并不完整,详细配置可参考数据手册或上网看视频或文章。这方面的资料实在是太多,我没必要再讲一遍。
首先要配置的是 SCON 寄存器。
如果需要使用中断进行数据的收发,则需要配置中断允许寄存器IE。
如上图所示,ES 位为串口中断使能,需要置 1。另外,STC89C52RC 单片机使用定时器 1 实现串口通信。换句话说,如果使用了单片机内置的串口通信功能,则不能再将定时器 T1 用在其它地方。由于使用了 T1,需要将此寄存器的 ET1 位置 1,以使能 T1。
最后,启动串口通信,就是启动 T1,而启动 T1,就是将寄存器 TR1 置 1。
数据的发送和接收只需访问 SBUF 寄存器即可,记住,每次只能发送或接收一个字节,这和上位机可以发送和接收多个字节的概念是不一样的。奇葩的是接收和发送共用一个缓存,即给 SBUF 赋值就是发送数据,读取 SBUF 的值就是接收数据。
波特率的配置要看数据手册本配置,比较麻烦,幸好有现成的公式,只需配置 T1 的计数器即可,公式为:
TH1 = TL1 = 256 - 晶振值/12/2/16/波特率
一般 51 单片机的时钟频率为 11059200,如果我们使用 9600 波特率,那么可以计算出:
TH1 = TL1 = 256 - 11059200/12/2/16/9600
TH1 = TL1 = 253 = 0xFD
如果时针频率为 12M,则使用 9600 波特率的结果为:
TH1 = TL1 = 256 - 12000000/12/2/16/9600 ≈ 252.745 ≈ 253
TH1 = TL1 = 0xFD
我这块开发板用的是 12M 晶振,结果杯具了,接收到的数据不正确。直到这一刻,我才知道为什么会有频率为 11059200 这样的奇葩晶振。之前参考别人电路图,别人用 12M,我觉得整数挺好,也跟着用,结果踩到坑了!没办法,板子打出来给学生用了,得想办法补救。降频,使用 2400 波特率:
TH1 = TL1 = 256 - 12000000/12/2/16/2400 ≈ 242.979 ≈ 243 = 0xF3
幸好 2400 波特率计算出的数字非常接近整数,只能使用这个波特率了。实践证明,使用此波特率可以正确地接收数据。
下面我们编写程序从上位机接收数据,做一个简单的,上位机通过串口向单片机一次只发送一个字节的数据,单片机收到数据后,将其以16进制的形式显示在最上方数码管上。
写程序还是需要电路原理图的:
代码如下:
#include<reg52.h>
//由于使用的是 40 IO 口的芯片,而 Keil 中只有 32 IO 口的寄存器影射文件
//所以 P4 只能自行映射,本程序用不到 P4 端口,所以不使用这句不影响程序运行
sfr P4 = 0xE8;
typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;
u8 LedChar[]= //共阳数码管真值表
{
0xC0,0xF9,0xA4,0xB0,0x99,0x92,0x82,0xF8,
0x80,0x90,0x88,0x83,0xC6,0xA1,0x86,0x8E
};
//数码管缓存,只用于上方两个数码管
u8 SegBuff[2] = {0xFF, 0xFF};
u8 recvByte = 0; //从上位机接收到的字节
u8 displayByte = 0; //当前数码管显示的字节
//配置定时器T0,5毫秒
void ConfigTimer0()
{
TMOD &= 0xF0; //清空T0配置
TMOD |= 0x01;
TH0 = 0xEC;
TL0 = 0x78;
ET0 = 1;
TR0 = 1;
}
//配置串口波特率
void ConfigUART(u16 baud)
{
SCON = 0x50;
TMOD &= 0x0F; //清空T1配置
TMOD |= 0x20;
//这里固定使用2400波特率,如果为11059200晶振,
//则可以使用后面的公式进行计算
TH1 = 0xF3;//256 - (12000000/12/32)/baud;
TL1 = TH1;
ET1 = 0;
ES = 1;
TR1 = 1;
}
void main()
{
EA = 1;
ConfigTimer0();
ConfigUART(9600);
while(1)
{
if(recvByte != displayByte)
{
displayByte = recvByte;
SegBuff[0] = LedChar[displayByte & 0x0F];
SegBuff[1] = LedChar[displayByte >> 4];
}
}
}
//串口中断服务函数
void Uart_isr() interrupt 4
{
if(RI)
{
RI = 0;
recvByte = SBUF;
}
if(TI)
{
TI = 0;
}
}
//定时器T0中断服务函数
void Timer0_isr() interrupt 1
{
static u8 i = 0;
TH0 = 0xEC;
TL0 = 0x78;
P1 = 0xE8;
P1 = P1 | i;
P0 = SegBuff[i];
i = (i+1) % 2;
}
一个字节的最大值为 0xFF,需要使用两个数码管来显示,此时需要一个定时器来轮流刷新。T1 已经被串口使用,只能使用 T0 了。
由于我使用的是 Type-C 接口接的串口,旧版烧写程序无法使用,所以使用的是最新版本的烧写程序,下载地址:
第一次打开时记得鼠标右键以管理员方式运行。按下图所示及说明烧写程序:
程序烧写完后,可以通过上位机向单片机发送数据。烧写程序自带串口助手,很方便啊,按下图所示进行操作:
每次向单片机发送的数字都会被显示在开发板上方的数码管上,如本文第一张图片所示。
下面讲解单片机如何向上位机发送数据。
数据的发送我们先来个简单的例子,热热身。单片机每隔一秒钟向上位机发送一个字节,从0发到255。
#include<reg52.h>
sfr P4 = 0xE8;
typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;
//配置定时器T0,50毫秒
void ConfigTimer0()
{
TMOD &= 0xF0; //清空T0配置
TMOD |= 0x01;
TH0 = 0x4C;
TL0 = 0x00;
ET0 = 1;
TR0 = 1;
}
//配置串口波特率
void ConfigUART(u16 baud)
{
SCON = 0x50;
TMOD &= 0x0F; //清空T1配置
TMOD |= 0x20;
TH1 = 0xF3;//256 - (12000000/12/32)/baud;
TL1 = TH1;
ET1 = 0;
ES = 1;
TR1 = 1;
}
void main()
{
EA = 1;
ConfigTimer0();
ConfigUART(2400);
while(1);
}
//串口中断服务函数
void Uart_isr() interrupt 4
{
if(RI)
{
RI = 0;
}
if(TI)
{
TI = 0;
}
}
//定时器T0中断服务函数
void Timer0_isr() interrupt 1
{
static u8 cnt = 0;
static u8 i = 0;
TH0 = 0x4C;
TL0 = 0x00;
if(cnt >= 20) //到达1秒钟
{ //每隔 1 秒向上位机发送数字0~255
cnt = 0;
SBUF = i; //发送数据
i = (i+1) % 256;
}
cnt++;
}
烧写完程序后,打开串口助手,注意点选【接收缓冲区】下方的【HEX模式】单选按钮。观察接收到的数据。
之前是热身,让我们以最低的成本了解串口编程,现在终于可以进入正题,制作一个游戏手柄。游戏很简单,接球游戏大家都玩过吧?窗口中有一个球在运动,碰到左右上边框会反弹。下方有一块可移动的木板,当球弹到下边框时,需要控制这块木板接住这个球,否则球就会掉出窗体,游戏结束。我们需要使用单片机上的两个按键控制木板的左右移动。
假设有两个键代表左和右,大家马上想到的解决方案应该是按左键向上位机发送 1,按右键向上位机发送 2。但事情没这么简单:
解决上述问题的方案可以有很多,但我之前做了键盘,掌握了键盘数据发送的原理,使用键盘的方案应当是最优解。
我们还是每次向上位机发送一个字节,使用最低两位来表示两个按键的状态,最低位表示左键状态,最低位为 0 表示左键处于弹起状态,为 1 表示左键处于按下状态。第 2 位表示右键状态,0 表示右键处于弹起状态,为 1 表示右键处于按下状态。此时一共有四种状态:
这种方案的好处是一方面可以很方便地表示两个键同时按下的状态;另一方面,可以很方便地表示按键连续按下的状态。比如,按下左键,发送 01,放开左键,发送 00,这期间就是按键处于连续按状的持续时间。也就是说,上位机从接收到 01 开始,就判断左键已经处于连续按下状态,直到接收到 00 为止。这种方案不但节省网络带宽,上位机处理起来还更为方便。
上代码:
#include<reg52.h>
sfr P4 = 0xE8;
typedef unsigned char u8;
typedef unsigned int u16;
typedef unsigned long u32;
u16 KeySta = 0xFFFF;//记录按键状态
//发送给上位机的键盘状态,用一个字节表示
//最低位(第1位,数字0表示)表示按键【KEY00】状态,1:按下;0:弹起
//第2位(数字3表示)表示按键【KEY03】状态,1:按下;0:弹起
u8 keyByte = 0;
u8 keyBytePrev = 0; //上一次串口发送的数据
//配置定时器T0,2毫秒
void ConfigTimer0()
{
TMOD &= 0xF0; //清空T0配置
TMOD |= 0x01;
TH0 = 0xF8;
TL0 = 0x30;
ET0 = 1;
TR0 = 1;
}
//配置串口波特率 2400
void ConfigUART(u16 baud)
{
SCON = 0x50;
TMOD &= 0x0F; //清空T1配置
TMOD |= 0x20;
TH1 = 0xF3; //256 - (12000000/12/32)/baud;
TL1 = TH1;
ET1 = 0;
ES = 1;
TR1 = 1;
}
void main()
{
u16 backup = 0xFFFF;
u16 diff;
u8 i;
P3 = 0xFF;
P4 = 0xFF;
P2 = 0xF7;
P0 = 0xFF;
P1 = 0xE8;
EA = 1;
ConfigTimer0();
ConfigUART(2400);
while(1)
{
if(backup != KeySta)
{
diff = backup ^ KeySta;
for(i=0;i<16;i++)
{ //diff中某位为1表明此键变动
if((diff & (1<<i)) != 0)
{
if((backup & (1<<i)) != 0)
{ //表明按键由弹起变为按下状态
if(i == 0)
{ //第1位置1
keyByte |= 0x01;
}
else if(i == 3)
{
//第2位置1
keyByte |= 0x02;
}
}
else
{ //表明按键由按下变为弹起状态
if(i == 0)
{ //第1位置0
keyByte &= 0xFE;
}
else if(i == 3)
{
//第2位置0
keyByte &= 0xFD;
}
}
}
}
backup = KeySta;
//如果按键状态发生了变化,则向上位机发送数据
if(keyByte != keyBytePrev)
{
SBUF = keyByte;
keyBytePrev = keyByte;
}
}
}
}
//串口中断服务函数
void Uart_isr() interrupt 4
{
if(RI)
{
RI = 0;
}
if(TI)
{
TI = 0;
}
}
void Timer0_isr() interrupt 1
{
static u16 keybuf[4]={0xFFFF,0xFFFF,0xFFFF,0xFFFF};
static u8 out = 0;
u8 i;
u8 keyIn = P2>>4;
u16 buf,flag=0xF000;
TH0 = 0xF8;
TL0 = 0x30;
for(i=0;i<4;i++)
{
buf = keybuf[out] & flag; //取出当前键所对应的4位
buf = (buf<<1) | ((keyIn & 1)<<((3-i)*4));//将键值移入缓冲区
buf=buf & flag;//去掉buf左移后多出的左边那位
keybuf[out]=(keybuf[out] & (~flag)) | buf;//buf加工好后填进数组
keyIn = keyIn >> 1;
if(buf == 0) //连续4次扫描为0
{
KeySta = KeySta & (~(1<<(4*out+i)));
}
else if(buf == flag) //连续4次扫描为1
{
KeySta = KeySta | (1<<(4*out+i));
}
flag = flag >> 4;
}
//执行下一次扫描输出
out=(out+1)&3;
P2=0xFF ^ (8>>out);
}
这个程序是在之前写的全键防抖程序的基础上写的,当时,为了显示自己牛B,写了个最小内存使用的防抖程序,就是使用一个16位整数表示按键状态,并用四个16位整数表示16个按键的之前32毫秒四次状态(每8毫秒记录一次),当四次状态全为 0 时,表示按键已经处于稳定的按下状态;当四次状态全为 1 时,表示按键已经处于稳定的弹起状态。
这样写其实没太大必要,那天代码翻出来我自己都看不懂了,其实用一个长度为16的字节数组存储按键连续状态即可。我写键盘代码的时候就没用这种方法。不过我也懒得改了,毕竟现成的能用,花这时间改它干啥。各位看不懂直接抄就 OK 了。
烧写程序,打开串口助手,按下开发板左上和右上按键(本开发板上标注为 KEY00 和 KEY03),观察上位机接收到的数据,通过观察,你大概就可以理解各种状态下所发送的数据。当你理解了这个程序,就可以去写键盘代码了。
下面来写上位机游戏,当然是使用 C#。界面很简单,拖一个 Timer 控件,Interval 属性设置为 10,也就是说每隔 0.01 秒刷一次窗体,达到 100MHz 刷新率。将窗体的 DoubleBuffered 属性设置为 true,也就是打开双缓冲。剩下的就是代码了:
using System;
using System.Drawing;
using System.IO.Ports;
using System.Windows.Forms;
namespace Game
{
public partial class MainForm : Form
{
public MainForm()
{
InitializeComponent();
}
SerialPort com = new SerialPort();
const int RECT_WIDTH = 80;//木板宽度
const int RECT_HEIGHT = 10;//木板高度
const int STEP_WIDTH = 5;//木板每次移动的像素
int rectLeft = 0;//木板左边的X轴坐标
bool beginLeft = false;
bool beginRight = false;
Pen pen = new Pen(Color.Blue, 2);
private void MainForm_Load(object sender, EventArgs e)
{
rectLeft = ClientSize.Width / 2 - RECT_WIDTH / 2;
com.PortName = "COM3"; //注意根据实际情况写入端口号
com.BaudRate = 2400; //注意根据实际的波特率写入
com.DataReceived += DataReceived;
try
{
com.Open();
}
catch (Exception ex)
{
MessageBox.Show("串口打开失败:" + ex.Message, "错误信息",
MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
timer1.Start();
}
private void MainForm_Paint(object sender, PaintEventArgs e)
{
Graphics g = e.Graphics;
//画木板
Rectangle rect = new Rectangle
(
rectLeft,
ClientSize.Height - RECT_HEIGHT - 5,
RECT_WIDTH,
RECT_HEIGHT
);
g.DrawRectangle(pen, rect);
}
private void timer1_Tick(object sender, EventArgs e)
{
if (beginLeft && rectLeft >= 0) //防止出左边框
{
rectLeft -= STEP_WIDTH; //左移
}
if (beginRight && rectLeft + RECT_WIDTH <= ClientSize.Width)//防止出右边框
{
rectLeft += STEP_WIDTH; //右移
}
this.Refresh();
}
void DataReceived(object sender, SerialDataReceivedEventArgs e)
{
byte[] buff = new byte[com.BytesToRead];
com.Read(buff, 0, buff.Length);
if (buff[0] == 1)
{
beginLeft = true; //开始连续左移状态
}
else if (buff[0] == 2)
{
beginRight = true; //开始连续右移状态
}
else
{
beginLeft = false;
beginRight = false;
}
}
}
}
给单片机通电,运行程序,界面如下:
界面很简单,就一个木板,按下单片机左右键,观察木板移动情况。可以发现,当按下按键时,木板会持续移动,直到放开按钮。各位可能要问了,不是接球游戏吗?球呢?本文是写给学生实训周用的,为避免学生走太多弯路,先带着走一段,属于前导课程。我要全写完了,实训做啥?
其实就是实训内容: